You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

564 lines
17 KiB

/**
* Model Schema API Endpoint
*
* Fetches parameter schema for a specific model from its provider.
* Returns simplified parameter list for UI rendering.
*
* GET /api/models/:modelId?provider=replicate|fal
*
* Headers:
* - X-Replicate-Key: Required for Replicate models
* - X-Fal-Key: Optional for fal.ai models
*
* Response:
* {
* success: true,
* parameters: ModelParameter[],
* cached: boolean
* }
*/
import { NextRequest, NextResponse } from "next/server";
import { ProviderType } from "@/types";
import { ModelParameter, ModelInput } from "@/lib/providers/types";
// Cache for model schemas (10 minute TTL)
const schemaCache = new Map<string, { parameters: ModelParameter[]; inputs: ModelInput[]; timestamp: number }>();
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
// Image input property patterns
const IMAGE_INPUT_PATTERNS = [
"image_url",
"image_urls",
"image",
"image_input",
"input_image",
"first_frame",
"last_frame",
"tail_image_url",
"start_image",
"end_image",
"reference_image",
"init_image",
"mask_image",
"control_image",
];
// Text input properties
const TEXT_INPUT_NAMES = ["prompt", "negative_prompt"];
// Properties that start with "image_" but are NOT image inputs
const IMAGE_PREFIX_EXCLUSIONS = ["image_size"];
// Parameters to filter out (internal/system params)
const EXCLUDED_PARAMS = new Set([
"webhook",
"webhook_events_filter",
"sync_mode",
"disable_safety_checker",
"go_fast",
"enable_safety_checker",
"output_format",
"output_quality",
"request_id",
]);
// Parameters we want to surface (user-relevant)
const PRIORITY_PARAMS = new Set([
"seed",
"num_inference_steps",
"inference_steps",
"steps",
"guidance_scale",
"guidance",
"negative_prompt",
"width",
"height",
"image_size",
"num_outputs",
"num_images",
"scheduler",
"strength",
"cfg_scale",
"lora_scale",
]);
interface SchemaSuccessResponse {
success: true;
parameters: ModelParameter[];
inputs: ModelInput[];
cached: boolean;
}
interface SchemaErrorResponse {
success: false;
error: string;
}
type SchemaResponse = SchemaSuccessResponse | SchemaErrorResponse;
/**
* Convert property name to human-readable label
*/
function toLabel(name: string): string {
return name
.replace(/_url$/, "")
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Check if property is an image input based on BOTH schema type AND name.
*
* Image inputs must be strings (URLs or base64) or arrays of strings.
* Integers, booleans, numbers with "image" in the name are NOT image inputs.
*/
function isImageInput(name: string, prop: Record<string, unknown>): boolean {
// First check: must be a string type (images are URLs or base64 strings)
// Integers, booleans, numbers are NEVER image inputs regardless of name
const propType = prop.type as string | undefined;
if (propType !== "string" && propType !== "array") {
return false;
}
// For arrays, check if items are strings (or unspecified - be lenient)
if (propType === "array") {
const items = prop.items as Record<string, unknown> | undefined;
// Only reject if items.type is explicitly specified AND not "string"
// Many schemas don't specify items type for image arrays
if (items && items.type && items.type !== "string") {
return false;
}
}
// Check exclusions (e.g., image_size is a parameter, not an image input)
if (IMAGE_PREFIX_EXCLUSIONS.includes(name)) {
return false;
}
// Check format hints (OpenAPI format field) - strong signal for image URLs
const format = prop.format as string | undefined;
if (format === "uri" || format === "data-uri" || format === "binary") {
// Only treat as image if name also suggests it's an image
if (IMAGE_INPUT_PATTERNS.includes(name) ||
name.endsWith("_image") ||
name.startsWith("image_") ||
name.includes("_image_")) {
return true;
}
}
// Check description for image-related keywords
const description = (prop.description as string || "").toLowerCase();
if (description.includes("image url") ||
description.includes("base64 image") ||
description.includes("data uri") ||
description.includes("image file") ||
description.includes("url of the image") ||
description.includes("path to image")) {
return true;
}
// Check explicit patterns (exact matches like "image_url", "image")
if (IMAGE_INPUT_PATTERNS.includes(name)) {
return true;
}
// More restrictive name pattern matching for strings
// Exclude names that suggest counts or settings rather than actual images
if (name.includes("_images") || // max_images, num_images
name.includes("guidance") || // image_guidance_scale
name.includes("generation") || // sequential_image_generation
name.includes("_count") || // image_count
name.includes("_size") || // image_size (already in exclusions but belt-and-suspenders)
name.includes("_scale")) { // image_scale
return false;
}
// Finally, check name patterns for remaining string types
return name.endsWith("_image") ||
name.startsWith("image_") ||
name.includes("_image_");
}
/**
* Check if property is a text input
*/
function isTextInput(name: string): boolean {
return TEXT_INPUT_NAMES.includes(name);
}
/**
* Resolve a $ref reference in OpenAPI schema
* E.g., "#/components/schemas/AspectRatio" -> schema object
*/
function resolveRef(
ref: string,
schemaComponents: Record<string, unknown>
): Record<string, unknown> | null {
// Parse reference path like "#/components/schemas/AspectRatio"
const match = ref.match(/^#\/components\/schemas\/(.+)$/);
if (!match) return null;
const schemaName = match[1];
const resolved = schemaComponents[schemaName] as Record<string, unknown> | undefined;
return resolved || null;
}
/**
* Convert OpenAPI schema property to ModelParameter
*/
function convertSchemaProperty(
name: string,
prop: Record<string, unknown>,
required: string[],
schemaComponents?: Record<string, unknown>
): ModelParameter | null {
// Skip excluded parameters
if (EXCLUDED_PARAMS.has(name)) {
return null;
}
// Determine type and extract enum from allOf/$ref if present
let type: ModelParameter["type"] = "string";
let enumValues: unknown[] | undefined;
let resolvedDefault: unknown;
let resolvedDescription: string | undefined;
const schemaType = prop.type as string | undefined;
const allOf = prop.allOf as Array<Record<string, unknown>> | undefined;
if (schemaType === "integer") {
type = "integer";
} else if (schemaType === "number") {
type = "number";
} else if (schemaType === "boolean") {
type = "boolean";
} else if (schemaType === "array") {
type = "array";
} else if (allOf && allOf.length > 0 && schemaComponents) {
// Handle allOf with $ref - resolve references and extract enum/type
for (const item of allOf) {
const itemRef = item.$ref as string | undefined;
if (itemRef) {
const resolved = resolveRef(itemRef, schemaComponents);
if (resolved) {
// Extract type from resolved schema
if (resolved.type === "integer") type = "integer";
else if (resolved.type === "number") type = "number";
else if (resolved.type === "boolean") type = "boolean";
// Extract enum from resolved schema
if (Array.isArray(resolved.enum)) {
enumValues = resolved.enum;
}
// Extract default from resolved schema
if (resolved.default !== undefined && resolvedDefault === undefined) {
resolvedDefault = resolved.default;
}
// Extract description from resolved schema
if (resolved.description && !resolvedDescription) {
resolvedDescription = resolved.description as string;
}
}
} else if (Array.isArray(item.enum)) {
// Direct enum in allOf item
enumValues = item.enum;
}
}
}
const parameter: ModelParameter = {
name,
type,
description: (prop.description as string | undefined) || resolvedDescription,
default: prop.default !== undefined ? prop.default : resolvedDefault,
required: required.includes(name),
};
// Add constraints
if (typeof prop.minimum === "number") {
parameter.minimum = prop.minimum;
}
if (typeof prop.maximum === "number") {
parameter.maximum = prop.maximum;
}
// Use enum from property directly, or from resolved $ref
if (Array.isArray(prop.enum)) {
parameter.enum = prop.enum;
} else if (enumValues) {
parameter.enum = enumValues;
}
return parameter;
}
interface ExtractedSchema {
parameters: ModelParameter[];
inputs: ModelInput[];
}
/**
* Fetch and parse schema from Replicate
*/
async function fetchReplicateSchema(
modelId: string,
apiKey: string
): Promise<ExtractedSchema> {
const [owner, name] = modelId.split("/");
const response = await fetch(
`https://api.replicate.com/v1/models/${owner}/${name}`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
},
}
);
if (!response.ok) {
throw new Error(`Replicate API error: ${response.status}`);
}
const data = await response.json();
// Extract schema from latest_version.openapi_schema
const openApiSchema = data.latest_version?.openapi_schema;
if (!openApiSchema) {
return { parameters: [], inputs: [] };
}
// Navigate to Input schema
const inputSchema = openApiSchema.components?.schemas?.Input;
if (!inputSchema || typeof inputSchema !== "object") {
return { parameters: [], inputs: [] };
}
// Pass components.schemas for $ref resolution
const schemaComponents = openApiSchema.components?.schemas as Record<string, unknown> | undefined;
return extractParametersFromSchema(inputSchema as Record<string, unknown>, schemaComponents);
}
/**
* Fetch and parse schema from fal.ai using Model Search API
* Uses: GET https://api.fal.ai/v1/models?endpoint_id={modelId}&expand=openapi-3.0
*/
async function fetchFalSchema(
modelId: string,
apiKey: string | null
): Promise<ExtractedSchema> {
const headers: Record<string, string> = {};
if (apiKey) {
headers["Authorization"] = `Key ${apiKey}`;
}
// Use fal.ai Model Search API with OpenAPI expansion
const url = `https://api.fal.ai/v1/models?endpoint_id=${encodeURIComponent(modelId)}&expand=openapi-3.0`;
const response = await fetch(url, { headers });
if (!response.ok) {
// Return empty params if API fails so generation still works
return { parameters: [], inputs: [] };
}
const data = await response.json();
// Response is { models: [{ openapi: {...}, ... }] }
const modelData = data.models?.[0];
if (!modelData?.openapi) {
return { parameters: [], inputs: [] };
}
const spec = modelData.openapi;
// Find POST endpoint with requestBody - paths are keyed by full endpoint path
let inputSchema: Record<string, unknown> | null = null;
for (const pathObj of Object.values(spec.paths || {})) {
const postOp = (pathObj as Record<string, unknown>)?.post as Record<string, unknown> | undefined;
const reqBody = postOp?.requestBody as Record<string, unknown> | undefined;
const content = reqBody?.content as Record<string, Record<string, unknown>> | undefined;
const jsonContent = content?.["application/json"];
if (jsonContent?.schema) {
const schema = jsonContent.schema as Record<string, unknown>;
// Handle $ref - resolve from components.schemas
if (schema.$ref && typeof schema.$ref === "string") {
const refPath = schema.$ref.replace("#/components/schemas/", "");
const resolvedSchema = spec.components?.schemas?.[refPath] as Record<string, unknown> | undefined;
if (resolvedSchema) {
inputSchema = resolvedSchema;
break;
}
} else if (schema.properties) {
inputSchema = schema;
break;
}
}
}
if (!inputSchema) {
return { parameters: [], inputs: [] };
}
// Pass components.schemas for $ref resolution
const schemaComponents = spec.components?.schemas as Record<string, unknown> | undefined;
return extractParametersFromSchema(inputSchema, schemaComponents);
}
/**
* Extract ModelParameters and ModelInputs from an OpenAPI schema object
*/
function extractParametersFromSchema(
schema: Record<string, unknown>,
schemaComponents?: Record<string, unknown>
): ExtractedSchema {
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined;
const required = (schema.required as string[]) || [];
if (!properties) {
return { parameters: [], inputs: [] };
}
const parameters: ModelParameter[] = [];
const inputs: ModelInput[] = [];
for (const [name, prop] of Object.entries(properties)) {
// Check if this is a connectable input (image or text)
// Pass both name AND prop to check schema type, not just name
if (isImageInput(name, prop)) {
inputs.push({
name,
type: "image",
required: required.includes(name),
label: toLabel(name),
description: prop.description as string | undefined,
isArray: prop.type === "array",
});
continue;
}
if (isTextInput(name)) {
inputs.push({
name,
type: "text",
required: required.includes(name),
label: toLabel(name),
description: prop.description as string | undefined,
isArray: prop.type === "array",
});
continue;
}
// Otherwise it's a parameter
const param = convertSchemaProperty(name, prop, required, schemaComponents);
if (param) {
parameters.push(param);
}
}
// Sort parameters: priority params first, then alphabetically
parameters.sort((a, b) => {
const aIsPriority = PRIORITY_PARAMS.has(a.name);
const bIsPriority = PRIORITY_PARAMS.has(b.name);
if (aIsPriority && !bIsPriority) return -1;
if (!aIsPriority && bIsPriority) return 1;
return a.name.localeCompare(b.name);
});
// Sort inputs: required first, then by type (image before text), then alphabetically
inputs.sort((a, b) => {
if (a.required !== b.required) return a.required ? -1 : 1;
if (a.type !== b.type) return a.type === "image" ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { parameters, inputs };
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ modelId: string }> }
): Promise<NextResponse<SchemaResponse>> {
// Await params before accessing properties
const { modelId } = await params;
const decodedModelId = decodeURIComponent(modelId);
const provider = request.nextUrl.searchParams.get("provider") as ProviderType | null;
if (!provider || (provider !== "replicate" && provider !== "fal")) {
return NextResponse.json<SchemaErrorResponse>(
{
success: false,
error: "Invalid or missing provider. Use ?provider=replicate or ?provider=fal",
},
{ status: 400 }
);
}
// Check cache
const cacheKey = `${provider}:${decodedModelId}`;
const cached = schemaCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return NextResponse.json<SchemaSuccessResponse>({
success: true,
parameters: cached.parameters,
inputs: cached.inputs,
cached: true,
});
}
try {
let result: ExtractedSchema;
if (provider === "replicate") {
// User-provided key takes precedence over env variable
const apiKey = request.headers.get("X-Replicate-Key") || process.env.REPLICATE_API_KEY;
if (!apiKey) {
return NextResponse.json<SchemaErrorResponse>(
{
success: false,
error: "Replicate API key required. Add REPLICATE_API_KEY to .env.local or configure in Settings.",
},
{ status: 401 }
);
}
result = await fetchReplicateSchema(decodedModelId, apiKey);
} else {
// User-provided key takes precedence over env variable
const apiKey = request.headers.get("X-Fal-Key") || process.env.FAL_API_KEY || null;
if (!apiKey) {
return NextResponse.json<SchemaErrorResponse>(
{
success: false,
error: "fal.ai API key not configured. Add FAL_API_KEY to .env.local or configure in Settings.",
},
{ status: 401 }
);
}
result = await fetchFalSchema(decodedModelId, apiKey);
}
// Cache the result
schemaCache.set(cacheKey, { ...result, timestamp: Date.now() });
return NextResponse.json<SchemaSuccessResponse>({
success: true,
parameters: result.parameters,
inputs: result.inputs,
cached: false,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
console.error(`[ModelSchema] Error fetching ${decodedModelId}: ${errorMessage}`);
return NextResponse.json<SchemaErrorResponse>(
{
success: false,
error: errorMessage,
},
{ status: 500 }
);
}
}